Access denied, Please sign in and make sure you have proper permission.

翻译 Turbo Rails Tutorial 第 5 章翻译

qinsicheng · 2023年06月08日 · 最后由 derlean 回复于 2024年06月11日 · 743 次阅读

Real-time updates with Turbo Streams

本章节,我们将学习如何使用 Action Cable 广播 Turbo Stream templates,来让我们的页面进行实时跟新。

Turbo Stream format 可以仅用几行代码就与 Action Cable 结合,来让我们的页面实时更新,当然与:群聊,通知,邮箱服务是类似的。

让我们用邮箱服务来举例,比如当我们收到一封新的邮箱,我们不想去手动的刷新让它显示,相反我们希望它能自己在页面上更新,而不需要我们操作什么。

而实现这一功能对于 Rails 来说很容易,因为在 Rails5 时就发布了 Active Cable。本章将要讨论的 Turbo Rails 的一部分是建立在 Action Cable 之上的,而实现该功能,也就更加简单了。

我们要做什么

来想象一下,如果有许多人同时使用我们的 quote 编辑器,他们更希望实时看到同事们都写了什么。

Quotes#index页面:

  • 任何时候一个成员创建了新的 quote,我们希望该 quote 立刻被加到我们的 quotes 列表的最上面
  • 任何时候一个成员修改了一个 quote,我们希望修改的内容,能立刻显示在页面上
  • 任何时候一个成员删除了一个 quote,我们希望被删除的内容,能立刻消失

这听起来很麻烦。但这个需求可以让我们的学习如何使用 Turbo Stream 来在首页中实时的更新,

使用 Turbo Stream 广播新建的 quotes

为了做到这一点,我们必须告诉Quote模型去广播新建的 quote 的 HTML 在创建后,让我们来改改

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  after_create_commit -> { broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self }, target: "quotes" }
end

让我们先写下这些代码,当我们在浏览器中感受一下,就能更清晰的知道代码的含义了

首先,我们使用了after_create_commit回调去通知 Rails,在每次向数据库内新加一条数据时,执行这个 lambda 表达式

第二段 lanbda 表达式中的代码就更复杂了,它会通知 rails,新建的 quote 对应的 HTML 应该被广播到那些订阅了quotes stream的用户那里,并在 DOM 中放到 id 为quotes的节点前面

我们将会解释到底该怎么做,但现在我们应注意生成的 HTML 是什么样子的。

<turbo-stream action="prepend" target="quotes">
  <template>
    <turbo-frame id="quote_123">
      <!-- The HTML for the quote partial -->
    </turbo-frame>
  </template>
</turbo-stream>

有没有感觉这个代码很眼熟,和我们上一节中QutoesController#create将新创建的数据放到 quotes 列表的前面,所生成的 HTML 代码是一样的。

唯一不同的是,这次的 HTML 是通过 WebSocket 传递的,而不是通过 ajax 的响应


注意:这里的例子我们是将新加的数据放到最前面,我们当然也可以使用broadcast_append_to去把新加的数据,放到列表的后面


为了能够订阅到quotes流,我们需要在Quotes#index中加入下面的代码

<%# app/views/quotes/index.html.erb %>

<%= turbo_stream_from "quotes" %>

<%# All the previous HTML markup %>

而这段代码生成的 HTML 是这样子的:

<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="very-long-string"
>
</turbo-cable-stream-source>

可以看到生成了一段来源于 Turbo JavaScript Library 的自定义标签,用订阅用户到在 channel 属性中命名的通道,更具体地说,是在 signed-stream-name 属性中命名的流。

Turbo::StreamsChannel里面的channel参数名就是 Action Cable channel 的名字,Turbo Rails 总会使用这个 channel,所以这个参数名始终是一样的。

signed-stream-name参数是使用quotes的一个签名,它是为了防止一些恶意用户干预并从流中获取我们的 HTML。这个我们会在下一章中细讲,现在你只需要知道这个长的字符串,解码后就quotes

现在所有的Quotes#index页面中的用户都能监听到这个Turbo::StreamsChannel,并等待quotes流中的订阅数据,每当给数据库添加一个新的数据时,这些用户将收到Turbo Stream format中的 HTML,并把数据放到相应的位置。

现在让我们看看是不是像我们预想的一样,下面会介绍两种方式去测试我们的代码

Testing Turbo Streams in the console

本章中,每次我们对Quote模型做修改时,我们都需要重启 rails console 在测试之前,否则会出现些意料之外的事儿


注意:我们在 console 中测试前,需要确保 Redis 被正确的配置在应用中。

在开发环境,你的config/cable.yml应该长下面的样子:

# config/cable.yml

development:
  adapter: redis
  url: redis://localhost:6379/1

# All the rest of the file

如果情况一致,那你可以忽略下面的提示了。


否则,你应该下载 Redis,然后运行:bin/rails turbo:install,它会修改config/cable.yml文件中的配置,如果没问题了,就可以继续往下了


现在我们在浏览器中,打开Quotes#index页面,然后在 rails console 中创建一个新的 quote:

Quote.create!(name: "Broadcasted quote")

然我们在 console logs 中看看发生了什么?第一件事儿:

TRANSACTION (0.1ms)  begin transaction
Quote Create (0.4ms)  INSERT INTO "quotes" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "Broadcasted quote"], ["created_at", "2021-10-16 12:03:54.401034"], ["updated_at", "2021-10-16 12:03:54.401034"]]
TRANSACTION (0.8ms)  commit transaction

可以看到,插入一条新的数据,然后事务提交,再往下:

Rendered quotes/_quote.html.erb (Duration: 0.5ms | Allocations: 285)
[ActionCable] Broadcasting to quotes: "<turbo-stream action=\"prepend\" target=\"quotes\"><template><turbo-frame id=\"quote_908005754\">\nThe HTML of our quotes/_quote.html.erb partial</turbo-frame></template></turbo-stream>"

内容很长,但确实很有趣的一部分

首先我们注意到,通过ActionCable广播了一段 HTML 到名字为quotes的流中,由于我们刚才在Quotes#index页面中加入了turbo_stream_from 'quotes',所以我们可以订阅到 Stream,并获取到它广播通知的 HTML

其次我们注意到,被广播通知的 HTML 是在 Turbo Stream format 中,它会通知 Turbo 去将中的内容放到quotes的前面,这不这是我们让模型去做的事儿吗?

最后我们看到了生成的中的 HTML 正是quotes/_quote.html.erb的数据,并且是我们刚刚创建的数据,当 Turbo 在前端获取到模版时,它就会放到 id 为 quotes 中 DOM 节点前面。

我们画个草图来说明一下,现在的Quotes#index页面长下面的样子:

想象一下,一个同事新创建了一条数据

由于after_create_commit的回调,当新创建数据后,broadcasts_prepend_to方法将被调用

而在浏览器中,我们应该可以看到命名为“Broadcasted quote”已经被实时的加到列表的前面

由于构建于 Action Cable 之上的 Turbo Rails,这些修改都能被立刻的显示在页面中,我们不再需要刷新页面,我们仅仅使用了几行代码就让我们的系统具有了实时性的特点。

Testing Turbo Streams with two browser windows

另一种方式就是,使用浏览器打开两个页面,一个页面进项操作,看另外一个页面是否可以实时更新。

Turbo Streams conventions and syntactic sugar

让我们来简化一下先前在Quote模型中的操作

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  after_create_commit -> { broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self }, target: "quotes" }
end

上面的代码中,我们指定了target: "quotes",而默认的 target 就是模型的复数形式,也就相当于我们这里的 quotes,所以根据约定,target 这部分我们可以省略

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  after_create_commit -> { broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self } }
end

还有两个约定,可以缩减我们的代码,底层中,partial and locals选项都有默认的值

partial的默认值等于 model 示例调用to_partial_path,对于Quote模型,就相当于quotes/quote

locals默认值等于{ model_name.element.to_sym => self },对于Quote模型,就相当于{quote:self}

所以最终我们的代码被简化为下面的样子:

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  after_create_commit -> { broadcast_prepend_to "quotes" }
end

根据约定大于配置,我们的代码只需要几行代码就可以完成任务了。

现在我们已经知道了 Turbo Streams 是如何运转的,让我们直接改进我们的增删改查代码。

Broadcasting quote updates with Turbo Streams

增加的效果已经出来了,现在我们让修改也生效

修改模型:

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  after_create_commit -> { broadcast_prepend_to "quotes" }
  after_update_commit -> { broadcast_replace_to "quotes" }
end

如果你去浏览器或者控制台测试,会发现功能已经做完了。

让我们在 rails console 测试一下,并解释一下发生了什么

Quote.first.update!(name: "Update from console")
Quote Load (0.3ms)  SELECT "quotes".* FROM "quotes" ORDER BY "quotes"."id" ASC LIMIT ?  [["LIMIT", 1]]
TRANSACTION (0.0ms)  begin transaction
Quote Update (0.3ms)  UPDATE "quotes" SET "name" = ?, "updated_at" = ? WHERE "quotes"."id" = ?  [["name", "Update from console"], ["updated_at", "2021-10-16 12:48:02.987708"], ["id", 908005754]]
TRANSACTION (1.6ms)  commit transaction

可以看到还是修改数据库,然后提交事务,当事务提交完毕后,Quote模型的 after_update_commit回调被触发,并且调用broadcast_replace_to方法

Rendered quotes/_quote.html.erb (Duration: 0.6ms | Allocations: 285)
[ActionCable] Broadcasting to quotes: "<turbo-stream action=\"replace\" target=\"quote_908005754\"><template><turbo-frame id=\"quote_908005754\">\nHTML from the quotes/quote partial</turbo-frame></template></turbo-stream>"

像上次一样,我们看到了quotes/quote局部页面的 HTML 被广播到quotes流中,与上次不同,这次是replace而不是prepend,目标的 DOM 节点是 id=quote_908005754 的 quote card,而它也就是要被更新的内容。

而 Turbo 拦截被获取的 HTML,并替换这个 quote

下面我们就来实现,如何实时的删除数据

Broadcasting quote deletion with Turbo Streams

修改模型:

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  after_create_commit -> { broadcast_prepend_to "quotes" }
  after_update_commit -> { broadcast_replace_to "quotes" }
  after_destroy_commit -> { broadcast_remove_to "quotes" }
end

测试一下,发现功能又完成了,我们在 rails console 中看看到底发生了什么

执行:确保数据库有数据

Quote.last.destroy!

删除数据,提交事务

Quote Load (0.3ms)  SELECT "quotes".* FROM "quotes" ORDER BY "quotes"."id" DESC LIMIT ?  [["LIMIT", 1]]
TRANSACTION (0.1ms)  begin transaction
Quote Destroy (0.4ms)  DELETE FROM "quotes" WHERE "quotes"."id" = ?  [["id", 908005754]]
TRANSACTION (1.4ms)  commit transaction

提交事务后,进行after_destroy_commit的模型回调,并调用broadcast_remove_to

[ActionCable] Broadcasting to quotes: "<turbo-stream action=\"remove\" target=\"quote_908005754\"></turbo-stream>"

页面中用户从quotes流中获取数据,并且让 Turbo 去删除 id 为quote_908005754的 DOM 节点,然后这部分就是要被删除的。

最终,这条 quote 数据就消失在Quotes#index页面中。

就这样,我们改造了我们的增删改查,不过在进入下一章前,我们聊聊性能。

Making broadcasting asynchronous with ActiveJob

现在我们的Quote模型长这个样子

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  after_create_commit -> { broadcast_prepend_to "quotes" }
  after_update_commit -> { broadcast_replace_to "quotes" }
  after_destroy_commit -> { broadcast_remove_to "quotes" }
end

我们可以通过使广播异步化去提升我们代码的性能,为了这一点,我们需要使用异步等价的语法去修改回调内容。

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  after_create_commit -> { broadcast_prepend_later_to "quotes" }
  after_update_commit -> { broadcast_replace_later_to "quotes" }
  after_destroy_commit -> { broadcast_remove_to "quotes" }
end

注意:prepend,replace 都有_later_to 方法,但 remove 没有,因为当一条 quote 被数据库删除了,那异步任务就没法在之后去检索这条数据执行任务了


让我们在 rails console 中测试一下,看一些有什么区别

Quote.create!(name: "Asynchronous quote")

看看最新的日志,我们发现创建数据的日志和之前一样,但是广播的部分被异步化了,一个Turbo::Streams::ActionBroadcastJob加入了队列,并附带了必要的数据,用来后续的广播

Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: 1eecd0c8-53fd-43ed-af8a-073b7d85c2fe) to Async(default) with arguments: "quotes", {:action=>:prepend, :target=>"quotes", :targets=>nil, :locals=>{:quote=>#<GlobalID:0x00007f9a39e861a8 @uri=#<URI::GID gid://hotwire-course/Quote/908005756>>}, :partial=>"quotes/quote"}

然后这个任务就被渲染为quotes/_quote.html.erb局部视图那样

Performing Turbo::Streams::ActionBroadcastJob (Job ID: 1eecd0c8-53fd-43ed-af8a-073b7d85c2fe) from Async(default) enqueued at 2021-10-16T17:24:32Z with arguments: "quotes", {:action=>:prepend, :target=>"quotes", :targets=>nil, :locals=>{:quote=>#<GlobalID:0x00007f9a3e03a630 @uri=#<URI::GID gid://hotwire-course/Quote/908005756>>}, :partial=>"quotes/quote"}

异步广播 Turbo Stream 是我们性能优化的首选之举。

更多的语法题

如果我们的模型拥有多个实时性任务,我们会注意到回调函数写的都很类似,而 Rails 就是一个约定大于配置的框架,所以让我们使用语法题去避免重复的语句,让我们来修改模型吧。

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  # after_create_commit -> { broadcast_prepend_later_to "quotes" }
  # after_update_commit -> { broadcast_replace_later_to "quotes" }
  # after_destroy_commit -> { broadcast_remove_to "quotes" }
  # Those three callbacks are equivalent to the following single line
  broadcasts_to ->(quote) { "quotes" }, inserts_by: :prepend
end

三个回到等同于下面的一行代码,我们将会在下一章(安全性)中讨论为什么需要 lambda 表达式。现在我们只需要知道,我们的增删改都被异步的广播到了quotes流中。

我们的模型别简化为:

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  broadcasts_to ->(quote) { "quotes" }, inserts_by: :prepend
end

Wrap up

让我们的项目具有实时性,我们只需要简单的两行代码

  • 模型中,我们设置增删改的回调方法,而得助于约定,三个回调被定义为一行代码
  • Quotes#index页面中,我们定义关注quotes

剩下的事儿就交给 Turbo 完成吧

下一章,我们将会聊聊安全相关内容,我们将讨论如何让 Turbo Stream 确保被不会广播数据到异常的用户那里。

The haunted tracks of Nightmare Kart are filled with traps and surprises. Keep your eyes on the road and be ready to react to sudden changes in the environment, like falling debris or ghostly apparitions.

需要 登录 后方可回复, 如果你还没有账号请 注册新账号